Skip to content

Phase 1: Rails API substrate v2 refactor#45

Merged
dadachi merged 44 commits into
mainfrom
phase1-rails-api
May 2, 2026
Merged

Phase 1: Rails API substrate v2 refactor#45
dadachi merged 44 commits into
mainfrom
phase1-rails-api

Conversation

@dadachi
Copy link
Copy Markdown
Contributor

@dadachi dadachi commented Apr 24, 2026

Summary

Phase 1 of the substrate v2 transformation — turns this Rails API from a queue-specific template (NFC/QR/scan flows, A001-style queue numbers, 7-tier queue-operator roles) into a generic single-resource CRUD substrate (ShopItemTag with name/description/position and a binary state) that nativeapptemplate-agent can rename and re-skin for any domain.

Design context: docs/nativeapptemplate-substrate-v2-overview.md. Step-by-step plan: docs/phase1-rails-api.md.

Scope: 21 commits, 82 files, +397 / −1686 lines. No DB-data migration required — existing roles jsonb keys for removed roles are silently ignored.

What changed

ItemTag schema

  • Renamed queue_numbername (NOT NULL, no uniqueness constraint)
  • Added description (text, nullable), position (integer, nullable), composite index on (shop_id, position)
  • Removed scan_state, customer_read_at, already_completed, plus the (shop_id, queue_number) unique index

Models

  • ItemTag: dropped enum :scan_state + AASM block, scan_tag!, complete_tag!, reset!, queue-format/uniqueness validations, turbo_stream callbacks; kept AASM :state (complete/idle events) and the limit_count validation
  • Shop: dropped latest_completed_item_tag, create_default_item_tags! (A001-A010 generator), reset!, full_reload_entire_page. New after_create :create_sample_item_tag creates exactly one "Sample" ItemTag for first-run UX (rescue-logged so failure doesn't block Shop creation)
  • AccountsShopkeeper::ROLES: [:admin, :senior_manager, :junior_manager, :senior_member, :junior_member, :guest][:admin, :member]

API surface

  • Removed: POST /scan, GET /scan_customer, the entire display/ namespace, DELETE /shops/:id/reset, app root route + static controller
  • Renamed: PATCH /item_tags/:id/reset/idle (matches AASM event name; clearer separation from "delete")
  • Same shape, new fields: POST /shops/:id/item_tags accepts { "item_tag": { "name", "description", "position", "state" } }; name is the only required field
  • New behavior: creating a Shop auto-creates 1 "Sample" ItemTag (was 10 A00x queue numbers)

Roles & Permissions

admin member
create_shops
update_shops
delete_shops
update_organizations
invitation
read_data

Collaborative SaaS model (Notion/Linear/Trello-style): both tiers can freely CRUD resources; only admin can manage org and invite members. Rationale in overview §6.10–6.11.

ItemTagPolicy resolves to Shop permissions — no separate ItemTag permissions exist.

State machine

AASM :state with complete (idled → completed) and idle (idled/completed → idled) events. Controller actions inline the state transitions and completed_by/completed_at writes — no *_tag! wrapper methods on the model, so the agent's identifier rename (e.g. ItemTagTodo) doesn't have to rewrite method bodies.

Cascading cleanup

  • Removed lib/tasks/shop.rake (only task was create_default_item_tags)
  • Removed orphan app/views/layouts/display.html.erb
  • Removed stale docs/pagination-item-tags.md (pagy is wired in the controller, doc was pre-implementation notes)
  • Madmin resources updated to expose new ItemTag attributes and 2-tier role booleans
  • Locales: "number tag(s)" → "item tag(s)", attribute labels updated, removed-validation error keys dropped
  • OpenAPI spec (docs/openapi.yaml) regenerated to match (-90/+31)
  • Brakeman.ignore fingerprint refreshed (the dynamic permit(*ROLES) line gets a new fingerprint when the constant changes; same intentional rationale)

Test plan

  • bin/rails test → 358 runs, 705 assertions, 0 failures, 0 errors
  • bin/ci → all 6 steps green (Setup, Style: Ruby, Security: Gem audit, Security: Brakeman, Tests: Rails, Tests: Seeds)
  • Fresh DB rebuild verifies counts:
    bin/rails db:drop db:create db:migrate && bin/rails db:seed_fu
    bin/rails runner "puts [Permission.count, Role.count].inspect"  # => [6, 2]
  • Manual API smoke test:
    • Sign in, create a shop → response includes the 1 auto-created "Sample" item_tag
    • POST /api/v1/shopkeeper/shops/:id/item_tags with { "item_tag": { "name": "Buy milk" } } → 201, response uses name not queue_number
    • PATCH /api/v1/shopkeeper/item_tags/:id/complete → state becomes completed, completed_at set
    • PATCH /api/v1/shopkeeper/item_tags/:id/idle → state back to idled, completed_at cleared
    • DELETE /api/v1/shopkeeper/item_tags/:id → 200
  • iOS / Android client tracking issues opened for: queue_numbername rename, removed scan/QR screens, role UI collapse to 2 tiers, reset route → idle rename (these are Phase 2–5 of the substrate v2 plan)

Rollback

Pre-refactor state preserved at tag v1.0.0-with-nfc and branch v1-with-nfc. To revert:

git checkout v1-with-nfc

dadachi and others added 30 commits April 24, 2026 08:06
… item

Removes queue-specific Shop methods (latest_completed_item_tag,
create_default_item_tags!, reset!, full_reload_entire_page) and
replaces the after_create callback with create_sample_item_tag,
which generates one generic 'Sample' ItemTag for first-run UX.

Cascades caller cleanup:
- shops_controller#reset action removed
- delete :reset route removed
- ShopPolicy#reset? removed
- lib/tasks/shop.rake (only task was create_default_item_tags) deleted
- Sort by (position, name) instead of queue_number
- Strip already_completed cache-purge hack from #complete
- Rename #reset action to #idle (matches AASM event); route updated
- Strong params: permit name, description, position, state
Removes Shop#complete_tag! and ItemTag#reset! (both carried queue-era
'_tag' residue and 'reset' semantics). Controller actions now call
AASM's generated complete!/idle! events directly with completed_by /
completed_at set inline. This keeps method names domain-agnostic so
nativeapptemplate-agent's identifier rename (e.g. ItemTag to Todo)
doesn't need to rewrite method bodies.
ItemTagSerializer:
- Drop queue_number, scan_state, customer_read_at, already_completed
- Add name, description, position

ShopSerializer:
- Drop scanned_item_tags_count (referenced removed scan_state)
- Drop display_shop_server_path (display route removed)
- Remove references to senior_manager?/junior_manager?/senior_member?/junior_member? predicates
- Both admin and member can perform all ItemTag operations (collaborative model)
- Rename reset? to idle? to match controller action

Note: member? predicate doesn't yet exist; defined when AccountsShopkeeper::ROLES is updated in Step 13.
Permissions: 6 generic CRUD primitives — create_shops, update_shops,
delete_shops, update_organizations, invitation, read_data.
Removes 5 queue-specific perms (manage_tags, write_info_to_tags,
reset_all_tags, complete_or_reset_tags, show_tag_info).

Roles: 2 tiers — admin, member. Removes 5 queue-operator tiers
(senior_manager, junior_manager, senior_member, junior_member, guest).

Mappings (collaborative SaaS / Notion-style):
- admin: all 6 permissions
- member: create_shops, update_shops, delete_shops, read_data
  (no update_organizations or invitation)

Applied identically to all 4 envs (development, test, staging, production).
A fresh db rebuild + db:seed_fu is required to drop orphan rows from
prior fixture loads.
AccountsShopkeeper::ROLES = [:admin, :member]. AccountsInvitation
inherits via constant aliasing, so both models drop the 4 queue-era
intermediate roles (senior_manager, junior_manager, senior_member,
junior_member) and 'guest'.

Madmin resources (AccountsShopkeeperResource, AccountsInvitationResource)
updated to expose only :admin and :member attributes.

Rolified concern is generic (iterates ROLES) so no change needed there.
Existing rows' 'roles' jsonb column may carry orphan keys for removed
roles — these are silently ignored since the model only reads ROLES.
- Rename 'number tag(s)' → 'item tag(s)' in user-visible strings
  (aligns with substrate v2 UI label rename in overview §2.4)
- Replace activerecord.attributes.item_tag.queue_number with
  name / description / position
- Drop queue_number format/uniqueness error keys (validations gone)

No removed permission/role tag refs existed in en.yml; no ja.yml present.
- ItemTagTest: rewrite around name/description/position; drop
  scan_tag!/complete_tag!/reset!/scan!/unscan! tests and queue_number
  format/uniqueness coverage; assert names may duplicate across and
  within shops
- ShopTest: replace default-item-tags / queue_number /
  latest_completed_item_tag / Shop#reset! tests with create_sample_item_tag
  success path and a stubbed-failure path covering the rescue branch
- AccountsShopkeeperTest: collapse per-tier permission/role-helper tests
  to admin and member only
- AccountsInvitationTest, AccountTest, RoleTest, ShopkeeperTest:
  rename junior_member/guest references to member
- ItemTagSerializerTest: drop scan_state/customer_read_at/already_completed
  assertions; switch the complete-flow test to the inlined controller
  pattern (set completed_by/at then call AASM complete!)
- ShopSerializerTest: drop scanned_item_tags_count and
  display_shop_server_path tests (attributes removed)
- AccountSerializerTest, AccountsShopkeeperSerializerTest,
  AccountsInvitationSerializerTest: rename old-tier role refs to member
- ItemTagPolicyTest: collapse per-tier tests to admin+member; rename
  reset? cases to idle?
- ShopPolicyTest: drop reset? tests (action removed in Step 7)
- PermissionPolicyTest: replace guest with admin/member coverage
- AccountPolicyTest, AccountsShopkeeperPolicyTest,
  AccountsInvitationPolicyTest: rename old-tier role refs to member
- ItemTagsControllerTest: switch payloads from queue_number to name;
  drop invalid-format and already_completed cases; rename reset to idle;
  cover the duplicate-name-allowed case
- ShopsControllerTest: drop the reset action test (action removed)
- BaseControllerTest: rewire the validation-error case to use blank name
  instead of duplicate queue_number
- Accounts/AccountsInvitations/AccountsShopkeepers/Permissions/
  RegistrationsControllerTest: rename old-tier role refs to member
- Drop the extra blank line at end of shops_controller_test.rb class body
  (Layout/EmptyLinesAroundClassBody, leftover from Step 7 reset action removal)
- Refresh brakeman.ignore: AccountsShopkeeper::ROLES collapse changed the
  expanded permit() literal, so the warning fingerprint shifted. Same
  intentional rationale (admin-gated role assignment); only the resolved
  role list and fingerprint differ.
README.md:
- Drop the 'real-time page updates for Number Tags Webpage' qualifier on Turbo
- Rename 'Number Tags (ItemTags)' to 'Item Tags' in the features list

CLAUDE.md:
- Correct the ItemTag description: name has no uniqueness constraint;
  describe the columns (name/description/position) and binary state instead

docs/openapi.yaml (-90/+31):
- ItemTag schema: drop queue_number/scan_state/customer_read_at/already_completed,
  add name (required)/description/position
- Shop schema: drop scanned_item_tags_count and display_shop_server_path
- /permissions meta: drop maximum_queue_number_length
- Roles: drop senior_manager/junior_manager/senior_member/junior_member/guest,
  add member (5 sites: AccountsShopkeeper attrs/update, AccountsInvitation
  attrs/create/update)
- Endpoints: remove DELETE /shops/{shopId}/reset; rename
  PATCH /item_tags/{itemTagId}/reset → /idle (resetItemTag → idleItemTag)
- ItemTag create/update request bodies switched to name+description+position+state

Also delete:
- docs/pagination-item-tags.md (pre-implementation notes; pagy is now wired in
  the controller, doc is stale)
- app/views/layouts/display.html.erb (orphaned after Step 3 display
  namespace removal — no controllers reference this layout)
Step 17 residual sweep caught references the per-step grep missed:
- config/settings.yml: drop maximum_queue_number_length and the
  item_tag.default_count / default_queue_number_length keys (only
  consumed by the removed format validator and create_default_item_tags!)
- PermissionsController#index meta: drop maximum_queue_number_length
  (clients no longer need to enforce the queue-format length cap)
- permissions_controller_test: drop the matching assertion
Without an explicit position, the sample row gets NULL, which Postgres
sorts last by default — surprising for a single-row first-run state and
fragile once a client adds more items without managing position
themselves. Setting position 1 gives the sample a definite slot and
keeps the (:position, :name) sort meaningful for clients that don't
implement reordering.
Both auto-clicker controllers were used only by the display namespace
queue-board views (now deleted in Step 3). The eager loader in
controllers/index.js needs no change — it just won't find them.
Stimulus itself is now essentially dead weight; defer the wider
JS-stack removal to a follow-up sweep alongside Turbo.
Without this, clients that don't manage position end up with NULL
positions on every item, the (shop_id, position) index goes unused,
and ordering between user-added items falls to the :name tiebreaker.

before_create callback no-ops when position was explicitly supplied,
so Shop's create_sample_item_tag (position: 1) and clients that send
their own positions are unaffected. Lighter than acts_as_list (no
gem, no decrement-on-delete machinery — that stays out per phase 1
pitfall #7); this is just a sensible append-to-end default.
The substrate v2 design (overview §2.1 table) grants create_shops,
update_shops, and delete_shops to both admin and member tiers.
Pre-refactor policy was stricter than the design called for:
- create? required owner? (so even non-owner admins couldn't create)
- update? required admin? (so members couldn't update)
- destroy? delegated to create? (so only owner could destroy)

Aligned with the design and with ItemTagPolicy's pattern. Tests
rewritten — two old assertions inverted (non-owner admin can now
create; member can now update), and two read-only true-for-all tests
specialised into admin and member variants for symmetry.
Assigns sequential position values per shop, ordered by created_at,
starting from current max+1, skipping callbacks/validations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without it, deliver_later jobs (e.g. invitation emails) sit in the
queue unprocessed since no worker runs in development. The Puma
plugin (config/puma.rb) already activates when this env var is set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dadachi and others added 14 commits April 27, 2026 10:32
bin/setup covers the dev-environment refresh flow; bin/update was a
legacy Rails generator artifact and was no longer referenced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixture-only role redesign (c42249e) left production with stale data:
- accounts_shopkeepers / accounts_invitations jsonb roles still keyed
  on senior_manager, junior_manager, senior_member, junior_member, guest
- orphan rows in roles, permissions, roles_permissions for the dropped
  tiers and queue-specific perms (manage_tags, write_info_to_tags,
  reset_all_tags, complete_or_reset_tags, show_tag_info)

The task folds non-admin keys to member, preserves admin, and deletes
orphan role/permission/roles_permission rows in a single transaction.
Idempotent — safe to re-run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Substrate v2 drops POST /scan and GET /scan_customer; the only
Universal Link rule in the manifest was /scan/*?item_tag_id=…, which
no longer resolves. No remaining iOS Universal Link routes in v2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
API-only Rails app — no Turbo runtime in use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the "switch organization" hint — Free client v2 has no
Organizations tab, so the guidance is misleading.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Covers ErrorsController, AdminUser, the static
ShopkeeperAuth::{ConfirmationResults,ResetPasswords} pages,
ShopkeeperMailer, and Shopkeeper::NotificationMailer (invited,
confirmation_instructions, reset_password_instructions).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ErrorsController: extract shared render_error helper for the
  duplicated JSON/HTML branching
- RegistrationsController: render_*_error hooks now delegate to a
  single private render_resource_error to remove the triple copy
- Account/Shop/ItemTag/AccountsShopkeeper: rename `the_limit_count`
  local to plain `limit`
- AccountsInvitation#set_token: collapse the unique-token loop to
  use `break` as the assignment value
- Shopkeeper: drop two commented-out send_devise_notification lines
  superseded by the custom mailer

Behavior preserved; full test suite passes (393 runs, 792 assertions).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Rate-limit shopkeeper sign-up to 5/IP/hour

Mass account creation is currently only constrained by the broad
300/IP/5min rack-attack req/ip cap. Add an endpoint-specific limit
on POST /shopkeeper_auth using Rails 8's built-in
ActionController::RateLimiting: 5 requests per IP per hour. When
exceeded, render 429 with a localized JSON error.

Switch the test cache from :null_store to :memory_store so the
limiter's counters can persist within a request sequence
(:null_store no-ops increments). Clear Rails.cache in the standard
test setup so throttle state doesn't leak between tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Tune sign-up rate limit to 10/3min (match jumpstart-pro reference)

Aligns with the reference codebase (jumpstart-pro-rails uses
`to: 10, within: 3.minutes` on user sign-up). The earlier 5/1.hour
was overly strict for legit users — a confused user with bad
password rules or autofill misfires could exhaust the quota and
get locked out for an hour. 10/3min absorbs realistic retry flows
while still constraining bots; per-IP limits are not the primary
defense against rotating-IP attackers anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#49)

* Fix two latent bugs in permissions and sign-in flows

PermissionsController#index compared `confirmed_*_version <
current_*_version`, where `current_version` returns nil if no row is
flagged current. `string < nil` raises ArgumentError → 500. Add a
`version_outdated?` helper that treats a missing current version as
"nothing to update".

ShopkeeperAuth::SessionsController#create unconditionally assigned
`request.headers["source"]` to current_platform and called
`save!(validate: false)`. Sign-ins without the source header
overwrote the user's stored platform with nil, bypassing the
presence/inclusion validation. Now skip the assignment when the
header is blank.

Adds regression tests for both paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Revert PermissionsController nil-version guard

Per review: a missing current PrivacyVersion/TermsVersion is a
server-side data integrity problem (no row published as current).
Crashing loud is preferable to silently telling the client "you're up
to date" — the latter would mask the data issue and mislead clients.

Keeps the SessionsController fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Require 'source' header on shopkeeper sign-in

Per direction: the source header (ios/android) is mandatory. Reject
the request with 401 when missing instead of skipping the
current_platform update — silently signing the user in without the
header would let API tools accumulate sessions that lack platform
attribution and bypass the presence/inclusion validation on Shopkeeper.

Adds the locale key and updates the regression test for the
blank-header path. Updates the existing "no params" test to send the
header so the bad_credentials assertion remains the path under test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Make 'source' header optional on shopkeeper sign-in

Reverts the earlier "require source header" form. Anti-mass-signup
is now handled at the right layer by the sign-up rate_limit
introduced in PR #50, so the sign-in header has no security job
left. current_platform is informational metadata; rejecting
sign-ins on missing metadata is too aggressive — it breaks
non-mobile callers (curl, CI, integration tools, future web client)
without a real benefit.

Skip the current_platform update when the header is blank: the
existing stored value is preserved (instead of being nuked to nil
by the original buggy code path). Drop the missing_source locale
key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Consolidates the rack-attack `logins/ip` and `logins/email`
throttles onto the controller using Rails 8's
ActionController::RateLimiting. Rules now sit next to the action
they guard, mirroring the sign-up `rate_limit` shipped earlier.
The general `req/ip` cap stays in rack-attack since `rate_limit`
is per-controller and middleware is the right layer for an
app-wide flood cap.

The email throttle uses `if: -> { params[:email].present? }` to
preserve the original "skip when no email" behavior — without
this, all email-less requests would share a single counter.

Adds an integration test covering both the IP-keyed throttle
(same IP, different emails) and the email-keyed throttle (same
email, rotating IPs).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verified-unreferenced removals (every grep below ran across
app/, lib/, config/ and matched only the definition site):

- gem "jbuilder" — no .jbuilder views, no JBuilder usage; all
  responses go through jsonapi-serializer
- gem "inline_svg" — no inline_svg helper calls anywhere
- locale `forgot_your_password` — no callers
- locale `send_me_reset_password_instructions` — no callers
- locale `api.shopkeeper.accounts.owner_required` — no callers
- locale `api.shopkeeper.accounts.admin_required` — no callers

Kept `validate_sign_up_params` / `validate_account_update_params`
overrides in RegistrationsController despite looking unused — the
parent class wires them up via `before_action` (verified in the
gem source).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…54)

Per docs-private/SUBSTRATE-NAMING-NORMALIZATION.md, all three substrates
should use the canonical NATIVEAPPTEMPLATE_ stem so the agent's existing
NativeAppTemplate -> <slug-pascal> rename pair handles every product-name
occurrence. The Rails repo has no boot/config references to these env
vars - only doc references that point users at the iOS/Android values -
so update them for parity.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Routine bundle update: bigdecimal, bootsnap, erb, ffi, irb, json,
minitest, net-imap, nokogiri, pagy, parallel, parser, propshaft,
puma, regexp_parser, rubocop, rubocop-ast, tailwindcss-ruby.
Tests, rubocop, brakeman, bundler-audit all clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@dadachi dadachi changed the title Phase 1: Rails API substrate v2 refactor Phase 1: Rails API substrate v2 refactor [render preview] May 2, 2026
@dadachi dadachi deployed to phase1-rails-api - nativeapptemplateapi-cable PR #45 May 2, 2026 05:29 — with Render Active
@dadachi dadachi deployed to phase1-rails-api - nativeapptemplateapi-cache PR #45 May 2, 2026 05:29 — with Render Active
@dadachi dadachi deployed to phase1-rails-api - nativeapptemplateapi-queue PR #45 May 2, 2026 05:29 — with Render Active
@dadachi dadachi deployed to phase1-rails-api - nativeapptemplateapi PR #45 May 2, 2026 05:29 — with Render Active
@dadachi dadachi temporarily deployed to phase1-rails-api - nativeapptemplateapi PR #45 May 2, 2026 05:30 — with Render Destroyed
@dadachi dadachi changed the title Phase 1: Rails API substrate v2 refactor [render preview] Phase 1: Rails API substrate v2 refactor May 2, 2026
@dadachi dadachi merged commit 45a0674 into main May 2, 2026
3 checks passed
@dadachi dadachi deleted the phase1-rails-api branch May 2, 2026 05:45
dadachi added a commit that referenced this pull request May 2, 2026
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dadachi added a commit that referenced this pull request May 10, 2026
Substrate post-Phase-1 (#45) is generic single-resource CRUD, not
queue-only — `ItemTag.name` can be a queue number ("A001"), a pet
name ("Mittens"), a task title, etc. The copy "Number %{number} is
up" only reads correctly for the queue case, and the agent's renamer
doesn't substitute the word "Number" (it's not in the rename plan),
so the wrong copy ships to every renamed app.

Change to "%{name} is ready" — generalizes cleanly across the queue,
reservation, vet-clinic, and task-tracker domains the substrate
targets. Body unchanged.

Test passes; rubocop clean.
dadachi added a commit that referenced this pull request May 10, 2026
* Add push notifications scaffolding via noticed v2

PR #1 of 5 in #58. Scaffolds the Rails-side groundwork for native push
notifications. Provider integration (APNs + FCM) and ItemTag AASM
wiring follow in PR #2 once APNs .p8 and FCM service-account JSON are
provisioned. Client work (free + paid iOS/Android) follows in PRs #3-5.

What lands here
- noticed v2 gem + the two engine migrations (Noticed::Event,
  Noticed::Notification, both UUID-keyed to match this substrate's
  primary_key_type)
- Device model + migration: shopkeeper-scoped, unique on
  [platform, token], last_active_at for staleness scoping; ios/android
  enum
- Api::V1::Shopkeeper::DevicesController:
    POST /api/v1/shopkeeper/devices  — idempotent upsert (rebinds token
                                       to current_shopkeeper if it
                                       previously belonged to someone
                                       else, e.g. shared device after
                                       sign-out/sign-in); 201 on
                                       create, 200 on touch
    DELETE /api/v1/shopkeeper/devices/:id — unregister (404 on someone
                                            else's device, scoped via
                                            current_shopkeeper.devices)
- DevicePolicy + DeviceSerializer following existing substrate
  conventions (BasePolicy + JSONAPI::Serializer)
- ApplicationNotifier base + example ItemTagCalledNotifier (no
  delivery methods wired yet — title/body/url are i18n-resolved via
  notification_methods so PR #2 just needs to add deliver_by :ios +
  :android and trigger from ItemTag's AASM complete event)
- Shopkeeper.has_many :devices (dependent: :destroy) and
  :notifications (as: :recipient, class: Noticed::Notification)
- Locale entries under notifiers.item_tag_called

Tests: 21 new runs (Device model 9, DevicesController 8, notifier 4),
0 failures. Full suite now 419 runs / 868 assertions / 0 failures /
0 errors / 0 skips. rubocop clean (239 files, 0 offenses).

* Wire deliver_by :action_push_native via Rails-native action_push_native

Install action_push_native 0.3.x and generate ApplicationPushNotification,
ApplicationPushDevice, ApplicationPushNotificationJob, and config/push.yml.
Add deliver_by :action_push_native to ItemTagCalledNotifier so push
notifications route through Rails 8.1's Action Push Native (single
abstraction over APNs + FCM) instead of the Noticed gem's per-platform
:ios / :fcm deliverers.

APNs/FCM credentials remain placeholders in config/push.yml — provision
via bin/rails credentials:edit before enabling delivery. Bridging the
existing Device registration API to ApplicationPushDevice (so registered
tokens actually flow into Action Push Native delivery) is a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Generalize notifier copy: %{number} → %{name}, drop "Number" prefix

Substrate post-Phase-1 (#45) is generic single-resource CRUD, not
queue-only — `ItemTag.name` can be a queue number ("A001"), a pet
name ("Mittens"), a task title, etc. The copy "Number %{number} is
up" only reads correctly for the queue case, and the agent's renamer
doesn't substitute the word "Number" (it's not in the rename plan),
so the wrong copy ships to every renamed app.

Change to "%{name} is ready" — generalizes cleanly across the queue,
reservation, vet-clinic, and task-tracker domains the substrate
targets. Body unchanged.

Test passes; rubocop clean.

* Generalize notifier: drop "Called" + state-verb copy (rename-resistant)

Reverses the previous "Number → name" / "is ready" decision once it
became clear that any state-verb baked into the substrate's notifier
title or class name fights the agent's domain-adapt step. The agent
extends/renames the AASM state machine per spec (idled/completed →
e.g. waiting/seated for restaurant, pending/seen for vet clinic), but
its rename plan only handles the four model-level tokens (Shop /
Shopkeeper / ItemTag / NativeAppTemplate). State names cascading into
notifier file/class/locale-key/title are out of scope for the
rename-safety contract (#57).

So the substrate's notifier ships state-verb-free:

- File:        item_tag_called_notifier.rb → item_tag_notifier.rb
- Class:       ItemTagCalledNotifier       → ItemTagNotifier
- Locale key:  notifiers.item_tag_called   → notifiers.item_tag
- Title:       "%{name} is ready"          → "%{name}"
- Body:        "Please proceed to %{shop}." → "%{shop}"

`ItemTag` itself IS in the rename plan, so file/class/locale-key
cascade through `item_tag → patient/reservation/todo` cleanly.
`%{name}` and `%{shop}` are interpolation keys, not renameable
tokens. Result: substrate copy survives any state-verb rewrite the
agent's adapt step does, at the cost of vague substrate copy. The
adapt step can rewrite richer per-domain copy when it wants.

Tests + rubocop clean.

* Swap notifier title/body: shop in title, item name in body

Push-notification UX convention is source-in-title, event-in-body —
WhatsApp (sender → message), Slack (channel → message), Calendar
(event → location). Shop is the recognizable persistent entity that
anchors the notification; item name is variable per-event content.

Title: %{name} → %{shop}
Body:  %{shop} → %{name}

Tests + rubocop clean.

* credentials.yml.tt: add action_push_native APNs + FCM placeholders

config/push.yml looks up Rails.application.credentials.dig(
:action_push_native, :apns, :key_id) and friends, but the credentials
template that seeds `bin/rails credentials:edit` on first generation
didn't expose those keys. Fresh developers would hit silent nil on
first push delivery without knowing where the lookup expected the
secret.

Adds the same shape Resend's api_key already follows: empty
placeholder under the documented key path. Comment notes which
inputs are needed (APNs key_id + .p8 contents, FCM service-account
JSON).

* push.yml: move team_id/topic/project_id to credentials too

Three deployment-specific values were still hard-coded as placeholders
in config/push.yml:

- apple.team_id    (Apple Developer team identifier — per-deployer)
- apple.topic      (iOS bundle identifier — per-deployment)
- google.project_id (Firebase project identifier — per-deployment)

These don't belong in source. apple.topic in particular is a rename-
pipeline trap: the agent renames the iOS bundle id when generating a
domain-customized variant (com.nativeapptemplate.* → com.<spec>.*),
but the rename pipeline only operates on code/locales/OpenAPI — not
on push.yml strings. So a hard-coded `your.bundle.identifier` here
silently desyncs from the renamed app's actual bundle id and push
delivery breaks with a non-obvious error.

Move all three to Rails.application.credentials.dig(:action_push_native,
...) so they're deploy-time configuration, not source-controlled
state. Add the same fields to the credentials.yml.tt template so
`bin/rails credentials:edit` exposes the expected key paths.

Tests + rubocop clean.

* openapi.yaml: document Device endpoints + schemas

Layer 1 of the agent's reviewer (per the agent's docs/SPEC.md)
checks OpenAPI parity between Rails ↔ iOS networking ↔ Android
repository layers. Adding the Device controller without the
corresponding spec entries means PRs #3-5 (the iOS/Android push
registration clients) wouldn't have a contract to integrate against
and would fail Layer 1 contract-parity scan.

Adds:
- Tag: Devices
- Path POST /devices: idempotent register; 201 on create, 200 on
  touch, 422 on validation error
- Path DELETE /devices/{deviceId}: 204 no_content, 404 if device
  isn't owned by current_shopkeeper
- Schemas: DeviceAttributes, Device, DeviceCreateRequest
  (jsonapi-style envelope to match the rest of the API)

YAML parses; paths now 25, schemas now 38.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant